page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { invitationsApi, InvitationInfo } from '@/lib/api';
  5. import { useAuth } from '@/lib/auth-context';
  6. export default function InvitePage() {
  7. const params = useParams();
  8. const token = params.token as string;
  9. const router = useRouter();
  10. const { user, token: authToken } = useAuth();
  11. const [invitation, setInvitation] = useState<InvitationInfo | null>(null);
  12. const [error, setError] = useState<string | null>(null);
  13. const [loading, setLoading] = useState(true);
  14. const [accepting, setAccepting] = useState(false);
  15. const [accepted, setAccepted] = useState(false);
  16. useEffect(() => {
  17. invitationsApi.verify(token)
  18. .then(({ invitation: inv }) => setInvitation(inv))
  19. .catch(() => setError('This invitation is invalid or has expired.'))
  20. .finally(() => setLoading(false));
  21. }, [token]);
  22. const isUsedOrExpired = invitation && (invitation.alreadyMember || invitation.isExpired);
  23. const handleAccept = async () => {
  24. if (!authToken) {
  25. // Redirect to login with invite token, come back after
  26. router.push(`/login?invite_token=${token}`);
  27. return;
  28. }
  29. setAccepting(true);
  30. try {
  31. await invitationsApi.accept(token);
  32. setAccepted(true);
  33. // Refresh projects list after a short delay
  34. setTimeout(() => router.push(`/projects`), 1500);
  35. } catch (err) {
  36. setError(err instanceof Error ? err.message : 'Failed to accept invitation');
  37. } finally {
  38. setAccepting(false);
  39. }
  40. };
  41. if (loading) {
  42. return (
  43. <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
  44. <div className="text-center">
  45. <div className="w-8 h-8 rounded-full animate-spin mx-auto mb-4"
  46. style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: 2 }} />
  47. <p className="text-sm" style={{ color: '#6B7280' }}>Verifying invitation…</p>
  48. </div>
  49. </div>
  50. );
  51. }
  52. if (error && !invitation) {
  53. return (
  54. <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
  55. <div className="card p-8 max-w-sm w-full mx-4 text-center">
  56. <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
  57. style={{ background: 'rgba(239,68,68,0.1)' }}>
  58. <svg className="w-6 h-6" style={{ color: '#EF4444' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  59. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
  60. </svg>
  61. </div>
  62. <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>Invalid Invitation</h1>
  63. <p className="text-sm mb-6" style={{ color: '#6B7280' }}>{error}</p>
  64. <button onClick={() => router.push('/projects')} className="btn btn-primary btn-md w-full">
  65. Go to Projects
  66. </button>
  67. </div>
  68. </div>
  69. );
  70. }
  71. {/* Show project info even for expired/used invitations */}
  72. if (isUsedOrExpired) {
  73. return (
  74. <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
  75. <div className="card p-8 max-w-sm w-full mx-4 text-center">
  76. <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
  77. style={{ background: invitation!.alreadyMember ? 'rgba(34,197,94,0.1)' : 'rgba(251,191,36,0.1)' }}>
  78. {invitation!.alreadyMember ? (
  79. <svg className="w-6 h-6" style={{ color: '#22C55E' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  80. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
  81. </svg>
  82. ) : (
  83. <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  84. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  85. </svg>
  86. )}
  87. </div>
  88. <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>
  89. {invitation!.alreadyMember ? 'Already Joined' : 'Invitation Expired'}
  90. </h1>
  91. <p className="text-sm mb-1" style={{ color: '#6B7280' }}>
  92. {invitation!.alreadyMember
  93. ? `You're already a member of ${invitation!.projectName}.`
  94. : `This invitation to ${invitation!.projectName} is no longer valid.`}
  95. </p>
  96. <p className="text-xs mb-6" style={{ color: '#4B5563' }}>
  97. {invitation!.alreadyMember
  98. ? 'Visit your projects to start collaborating.'
  99. : 'Ask the project admin to send a new invitation.'}
  100. </p>
  101. <button
  102. onClick={() => user ? router.push('/projects') : router.push('/login')}
  103. className="btn btn-primary btn-md w-full"
  104. >
  105. {user ? 'Go to Projects' : 'Sign In'}
  106. </button>
  107. </div>
  108. </div>
  109. );
  110. }
  111. if (accepted) {
  112. return (
  113. <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
  114. <div className="card p-8 max-w-sm w-full mx-4 text-center">
  115. <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
  116. style={{ background: 'rgba(34,197,94,0.1)' }}>
  117. <svg className="w-6 h-6" style={{ color: '#22C55E' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  118. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
  119. </svg>
  120. </div>
  121. <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>Welcome!</h1>
  122. <p className="text-sm" style={{ color: '#6B7280' }}>
  123. You've joined <strong style={{ color: '#F9FAFB' }}>{invitation?.projectName}</strong>. Redirecting…
  124. </p>
  125. </div>
  126. </div>
  127. );
  128. }
  129. return (
  130. <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
  131. <div className="card p-8 max-w-sm w-full mx-4">
  132. {/* Project icon */}
  133. <div className="w-12 h-12 rounded-2xl flex items-center justify-center mx-auto mb-4"
  134. style={{ background: 'rgba(99,102,241,0.1)' }}>
  135. <svg className="w-6 h-6" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  136. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
  137. d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
  138. </svg>
  139. </div>
  140. <h1 className="text-lg font-semibold text-center mb-1" style={{ color: '#F9FAFB' }}>
  141. You're invited
  142. </h1>
  143. <p className="text-sm text-center mb-1" style={{ color: '#9CA3AF' }}>
  144. to join project
  145. </p>
  146. <p className="text-xl font-bold text-center mb-6" style={{ color: '#818CF8' }}>
  147. {invitation?.projectName}
  148. </p>
  149. {/* Role badge */}
  150. <div className="flex justify-center mb-6">
  151. <span className="badge badge-brand capitalize">
  152. {invitation?.role.toLowerCase()}
  153. </span>
  154. </div>
  155. {invitation?.alreadyMember ? (
  156. <div className="text-center">
  157. <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
  158. You're already a member of this project.
  159. </p>
  160. <button onClick={() => router.push('/projects')} className="btn btn-primary btn-md w-full">
  161. Go to Projects
  162. </button>
  163. </div>
  164. ) : !user ? (
  165. <div className="text-center">
  166. <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
  167. Create an account or sign in to accept this invitation.
  168. </p>
  169. <div className="space-y-2">
  170. <button onClick={handleAccept} className="btn btn-primary btn-md w-full">
  171. Sign in to accept
  172. </button>
  173. <button
  174. onClick={() => router.push(`/register?invite_token=${token}`)}
  175. className="btn btn-secondary btn-md w-full"
  176. >
  177. Create account
  178. </button>
  179. </div>
  180. </div>
  181. ) : invitation?.isOwnInvitation ? (
  182. <div className="text-center">
  183. <p className="text-sm mb-4" style={{ color: '#6B7280' }}>
  184. This invitation was sent to <strong style={{ color: '#F9FAFB' }}>{user.email}</strong>
  185. </p>
  186. <button
  187. onClick={handleAccept}
  188. disabled={accepting}
  189. className="btn btn-primary btn-md w-full"
  190. >
  191. {accepting ? 'Joining…' : 'Accept & Join Project'}
  192. </button>
  193. </div>
  194. ) : (
  195. <div className="text-center">
  196. <div className="w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-3"
  197. style={{ background: 'rgba(239,68,68,0.1)' }}>
  198. <svg className="w-4 h-4" style={{ color: '#EF4444' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
  199. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
  200. </svg>
  201. </div>
  202. <p className="text-sm mb-2" style={{ color: '#F87171' }}>Email mismatch</p>
  203. <p className="text-xs mb-4" style={{ color: '#6B7280' }}>
  204. This invitation was sent to <strong>{invitation?.email}</strong>.<br/>
  205. You're currently logged in as <strong>{user.email}</strong>.
  206. </p>
  207. <p className="text-xs" style={{ color: '#4B5563' }}>
  208. Sign in with the correct account, or ask the project admin to resend the invitation.
  209. </p>
  210. </div>
  211. )}
  212. {/* Expiry */}
  213. {invitation?.expiresAt && (
  214. <p className="text-xs text-center mt-6" style={{ color: '#4B5563' }}>
  215. Expires {new Date(invitation.expiresAt).toLocaleDateString()}
  216. </p>
  217. )}
  218. {error && (
  219. <p className="text-xs text-center mt-3" style={{ color: '#F87171' }}>{error}</p>
  220. )}
  221. </div>
  222. </div>
  223. );
  224. }